本系列文已改編成書「甚麼?網頁也可以做派對遊戲?使用 Vue 和 babylon.js 打造 3D 派對遊戲吧!」
書中不只重構了程式架構、改善了介面設計,還新增了 2 個新遊戲呦!ˋ( ° ▽、° )
新遊戲分別使用了陀螺儀與震動回饋,趕快買書來研究研究吧!ლ(╹∀╹ლ)
在此感謝深智數位的協助,歡迎大家前往購書,鱈魚感謝大家 (。・∀・)。
助教:「所以到底差在哪啊?沒圖沒真相,被你坑了都不知道。(´。_。`)」
鱈魚:「你對我是不是有甚麼很深的偏見啊 (っ °Д °;)っ,來人啊,上連結!」
玩家加入房間成功後,應該要讓玩家畫面跳轉至搖桿畫面,現在來讓我們打造玩家的搖桿吧!( ´ ▽ ` )ノ
預計不同的遊戲狀態會有不同的專用搖桿,首先來建立最外層的搖桿容器頁面。
src\views\player-gamepad.vue
<template>
<div class="w-full h-full bg-black">
<router-view />
</div>
</template>
<script setup lang="ts">
import { useLoading } from '../composables/use-loading';
const loading = useLoading();
function init() {
loading.hide();
}
init();
</script>
並加到 Router 中。
src\router\router.ts
...
export enum RouteName {
...
PLAYER_GAMEPAD = 'player-gamepad',
}
const routes: Array<RouteRecordRaw> = [
...
{
path: `/player-gamepad`,
name: RouteName.PLAYER_GAMEPAD,
component: () => import('../views/player-gamepad.vue'),
children: []
},
{
path: '/:pathMatch(.*)*',
redirect: '/'
},
]
...
現在讓我們在 the-home 加入 Router,於加入房間後跳轉頁面吧。♪( ◜ω◝و(و
...
<script setup lang="ts">
...
async function joinParty() {
$q.dialog({
component: DialogJoinParty,
}).onOk(async () => {
$q.notify({
type: 'positive',
message: '加入房間成功'
});
await loading.show();
router.push({
name: RouteName.PLAYER_GAMEPAD
});
});
}
</script>
...
現在加入房間後,會在讀取畫面結束時跳到一片黑暗之中了!( •̀ ω •́ )(?
接著新增 player-gamepad-lobby 組件,用於提供大廳專用搖桿按鍵。
src\views\player-gamepad-lobby.vue
<template>
<div class="w-full h-full flex text-white select-none">
</div>
</template>
<script setup lang="ts">
import { useLoading } from '../composables/use-loading';
const loading = useLoading();
function init() {
loading.hide();
}
init();
</script>
隱藏 loading 的工作改成交給 player-gamepad-lobby,所以我們把 player-gamepad 中的部份刪除。( ‧ω‧)ノ╰(‧ω‧ )
src\views\player-gamepad.vue
<template>
<div class="w-full h-full bg-black">
<router-view />
</div>
</template>
<script setup lang="ts">
function init() { }
init();
</script>
現在讓我們把 player-gamepad-lobby 加到 router 中吧。
src\router\router.ts
...
export enum RouteName {
...
PLAYER_GAMEPAD_LOBBY = 'player-gamepad-lobby',
}
const routes: Array<RouteRecordRaw> = [
...
{
path: `/player-gamepad`,
...
children: [
{
path: `lobby`,
name: RouteName.PLAYER_GAMEPAD_LOBBY,
component: () => import('../views/player-gamepad-lobby.vue')
},
]
},
...
]
...
在 player-gamepad 加入跳轉至 player-gamepad-lobby 的邏輯。
src\views\player-gamepad.vue
...
<script setup lang="ts">
import { RouteName } from '../router/router';
import { useRouter } from 'vue-router';
import { useGameConsoleStore } from '../stores/game-console.store';
const gameConsoleStore = useGameConsoleStore();
const router = useRouter();
function init() {
if (!gameConsoleStore.roomId) {
router.push({
name: RouteName.HOME
});
return;
}
if (gameConsoleStore.status === 'lobby') {
router.push({
name: RouteName.PLAYER_GAMEPAD_LOBBY
});
}
}
init();
</script>
現在若嘗試加入遊戲會發現畫面卡在 loading,沒辦法跳到搖桿畫面。
原因很簡單,搖桿畫面依照遊戲機狀態進行跳轉,但是我們都還沒實作同步遊戲機狀態功能,所以現在來讓我們完成最重要的部分:「同步遊戲機狀態」。
首先在 socket.type 新增事件定義。
src\types\socket.type.ts
import { Socket } from 'socket.io-client';
import { UpdateGameConsoleState } from '../stores/game-console.store';
...
interface OnEvents {
...
'game-console:state-update': (data: Required<UpdateGameConsoleState>) => void;
}
interface EmitEvents {
...
'game-console:state-update': (data: UpdateGameConsoleState) => void;
}
...
接著在 use-client-game-console 新增設定遊戲狀態的 function。
src\composables\use-client-game-console.ts
...
import { GameConsoleStatus, GameName, UpdateGameConsoleState, useGameConsoleStore } from '../stores/game-console.store';
...
export function useClientGameConsole() {
const { client, connect, close } = useSocketClient();
const gameConsoleStore = useGameConsoleStore();
function setStatus(status: `${GameConsoleStatus}`) {
gameConsoleStore.updateState({
status
});
if (!client?.value?.connected) {
return Promise.reject('client 尚未連線');
}
client.value.emit('game-console:state-update', {
status
});
}
function setGameName(gameName: `${GameName}`) {
gameConsoleStore.updateState({
gameName
});
if (!client?.value?.connected) {
return Promise.reject('client 尚未連線');
}
client.value.emit('game-console:state-update', {
gameName
});
}
...
return {
...
/** 設定遊戲狀態,會自動同步至房間內所有玩家 */
setStatus,
/** 設定遊戲名稱,會自動同步至房間內所有玩家 */
setGameName,
}
}
最後在 game-console-lobby 中使用 useClientGameConsole。
src\views\game-console-lobby.vue
<script setup lang="ts">
...
import { useClientGameConsole } from '../composables/use-client-game-console';
const loading = useLoading();
const gameConsole = useClientGameConsole();
function init() {
gameConsole.setStatus('lobby');
loading.hide();
}
init();
</script>
以上 game-console 設定狀態的部分基本上 OK 了,接下來我們還需要:
首先讓我們移駕到伺服器專案吧!(≧∇≦)ノ
第一步是讓我們新增 game-console 模組,調整 CLI 自動生成內容並引入重要模組。
src\game-console\game-console.module.ts
import { Module } from '@nestjs/common';
import { GameConsoleService } from './game-console.service';
import { GameConsoleGateway } from './game-console.gateway';
import { WsClientModule } from 'src/ws-client/ws-client.module';
import { RoomModule } from 'src/room/room.module';
@Module({
imports: [WsClientModule, RoomModule],
providers: [GameConsoleGateway, GameConsoleService],
exports: [GameConsoleService],
})
export class GameConsoleModule {
//
}
src\game-console\game-console.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { RoomService } from 'src/room/room.service';
import { WsClientService } from 'src/ws-client/ws-client.service';
@Injectable()
export class GameConsoleService {
private logger: Logger = new Logger(GameConsoleService.name);
constructor(
private readonly roomService: RoomService,
private readonly wsClientService: WsClientService,
) {
//
}
}
src\game-console\game-console.gateway.ts
import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
import { GameConsoleService } from './game-console.service';
import { UtilsService } from 'src/utils/utils.service';
import { WsClientService } from 'src/ws-client/ws-client.service';
import { Logger } from '@nestjs/common';
@WebSocketGateway()
export class GameConsoleGateway {
private logger: Logger = new Logger(GameConsoleGateway.name);
constructor(
private readonly gameConsoleService: GameConsoleService,
private readonly utilsService: UtilsService,
private readonly wsClientService: WsClientService,
) {
//
}
}
手動新增 game-console.type 定義資料型別。
src\game-console\game-console.type.ts
export enum GameConsoleStatus {
/** 首頁 */
HOME = 'home',
/** 大廳等待中 */
LOBBY = 'lobby',
/** 遊戲中 */
PLAYING = 'playing',
}
export enum GameName { }
export interface Player {
clientId: string;
}
export interface GameConsoleState {
status: `${GameConsoleStatus}`;
gameName?: `${GameName}`;
players: Player[];
}
export type UpdateGameConsoleState = Partial<GameConsoleState>;
接著新增 socket 事件定義!◝( •ω• )◟
types\socket.type
...
import { UpdateGameConsoleState } from 'src/game-console/game-console.type';
export interface OnEvents {
...
'game-console:state-update': (data: UpdateGameConsoleState) => void;
}
export interface EmitEvents {
...
'game-console:state-update': (data: GameConsoleState) => void;
}
...
現在讓我們實作 game-console 模組邏輯。
首先 game-console.service 負責儲存或提供 game-console 狀態
src\game-console\game-console.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { defaults, merge } from 'lodash';
import { RoomService } from 'src/room/room.service';
import { WsClientService } from 'src/ws-client/ws-client.service';
import { GameConsoleState, Player } from './game-console.type';
/** GameConsoleService 儲存之狀態不需包含 players
* 因為 players 數值由 roomService 提供,所以這裡忽略
*/
type GameConsoleData = Omit<GameConsoleState, 'players'>;
const defaultState: GameConsoleData = {
status: 'home',
gameName: undefined,
};
@Injectable()
export class GameConsoleService {
private logger: Logger = new Logger(GameConsoleService.name);
/** key 為 founder 之 clientId */
private readonly gameConsolesMap = new Map<string, GameConsoleData>();
constructor(
private readonly roomService: RoomService,
private readonly wsClientService: WsClientService,
) {
//
}
setState(founderId: string, state: Partial<GameConsoleData>) {
const oriState = this.gameConsolesMap.get(founderId);
let newState: GameConsoleData;
if (oriState) {
newState = merge(oriState, state);
} else {
newState = defaults(state, defaultState);
}
this.gameConsolesMap.set(founderId, newState);
}
getState(founderId: string) {
const data = this.gameConsolesMap.get(founderId);
// 取得房間
const room = this.roomService.getRoom({
founderId,
});
if (!room) {
return undefined;
}
// 加入玩家
const players: Player[] = room.playerIds.map((playerId) => ({
clientId: playerId,
}));
const state: GameConsoleState = {
...data,
status: data?.status ?? 'home',
players,
};
return state;
}
}
game-console.gateway 接收狀態更新事件。
src\game-console\game-console.gateway.ts
...
import { ClientSocket, OnEvents } from 'types/socket.type';
import { UpdateGameConsoleState } from './game-console.type';
@WebSocketGateway()
export class GameConsoleGateway {
...
@SubscribeMessage<keyof OnEvents>('game-console:state-update')
async handleGameConsoleStateUpdate(
socket: ClientSocket,
state: UpdateGameConsoleState,
) {
const client = this.wsClientService.getClient({
socketId: socket.id,
});
if (!client) return;
const { status, gameName } = state;
this.gameConsoleService.setState(client.id, { status, gameName });
}
}
現在我們已經可以從遊戲機網頁發送狀態更新至伺服器,並在伺服器儲存遊戲機網頁狀態。
還差狀態更新時,伺服器廣播至房間內所有玩家功能。
新增廣播用 method。
src\game-console\game-console.service.ts
...
import { Server } from 'socket.io';
import { EmitEvents, OnEvents } from 'types/socket.type';
...
@Injectable()
export class GameConsoleService {
...
async broadcastState(
founderId: string,
server: Server<OnEvents, EmitEvents>,
) {
const room = this.roomService.getRoom({
founderId,
});
if (!room) {
this.logger.warn(`此 founderId 未建立任何房間 : ${founderId}`);
return;
}
const state = this.getState(founderId);
if (!state) {
this.logger.warn(`此 founderId 不存在 state : ${founderId}`);
return;
}
const sockets = await server.in(room.id).fetchSockets();
sockets.forEach((socketItem) => {
socketItem.emit('game-console:state-update', state);
});
}
}
最後在 game-console.gateway 呼叫廣播功能,由於需要直接呼叫 socket server,所以同時新增 server 成員。
src\game-console\game-console.gateway.ts
import {
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server } from 'socket.io';
...
import { ClientSocket, EmitEvents, OnEvents } from 'types/socket.type';
...
@WebSocketGateway()
export class GameConsoleGateway {
...
@WebSocketServer()
private server!: Server<OnEvents, EmitEvents>;
...
@SubscribeMessage<keyof OnEvents>('game-console:state-update')
async handleGameConsoleStateUpdate( ... ) {
...
// 廣播狀態
this.gameConsoleService.broadcastState(client.id, this.server);
}
}
以上我們完成「伺服器對同一個房間內的所有玩家廣播 game-console 狀態更新事件」,現在還差「玩家搖桿要監聽狀態變更事件,並要有主動發出請求狀態的能力」。
讓我們繼續努力!ヽ(●`∀´●)ノ,讓我們回到網頁專案。
在 use-client-player 中提供 game-console 狀態更新觸發的 hook 吧!
src\composables\use-client-player.ts
import { computed, onBeforeUnmount } from 'vue';
...
import { createEventHook } from '@vueuse/core';
import { UpdateGameConsoleState } from '../stores/game-console.store';
export function useClientPlayer() {
...
const stateUpdateHook = createEventHook<UpdateGameConsoleState>();
client?.value?.on('game-console:state-update', stateUpdateHook.trigger);
onBeforeUnmount(() => {
client?.value?.removeListener('game-console:state-update', stateUpdateHook.trigger);
});
return {
joinRoom,
onGameConsoleStateUpdate: stateUpdateHook.on,
}
}
在 player-gamepad 中使用此 hook,持續接收 game-console 狀態更新。
src\views\player-gamepad.vue
...
<script setup lang="ts">
...
const gameConsoleStore = useGameConsoleStore();
const router = useRouter();
const player = useClientPlayer();
function init() {
if (!gameConsoleStore.roomId) {
router.push({
name: RouteName.HOME
});
return;
}
player.onGameConsoleStateUpdate((state) => {
const { status } = state;
console.log(`[ onGameConsoleStateUpdate ] state : `, state);
gameConsoleStore.updateState(state);
if (status === 'home') {
router.push({
name: RouteName.HOME
});
}
if (status === 'lobby') {
router.push({
name: RouteName.PLAYER_GAMEPAD_LOBBY
});
}
});
}
init();
</script>
如此便可以在遊戲機網頁狀態更新時,玩家的網頁也會自動跳轉了!
讀者:「終於好了嗎?快睡著惹…(›´ω`‹ )」
鱈魚:「但是現在玩家加入遊戲後還是一樣會卡在 loading 畫面呦。ᕕ( ゚ ∀。)ᕗ」
讀者:「所以是怎樣啦!⎝(・ω´・⎝)」
別氣別氣,這是因為玩家加入遊戲,根本就不會觸發遊戲機的狀態更新,所以還差最後一步,「玩家網頁可以請求取得遊戲機狀態」的功能。
一樣讓我們在 socket.type 新增事件。
src\types\socket.type.ts
...
interface EmitEvents {
'player:join-room': (roomId: string, callback?: (err: any, res: SocketResponse<Room>) => void) => void;
'player:request-game-console-state': () => void;
...
}
...
接著在 use-client-player 中提供請求遊戲機狀態的 function。
src\composables\use-client-player.ts
...
export function useClientPlayer() {
...
async function requestGameConsoleState() {
if (!client?.value?.connected) {
return Promise.reject('client 尚未連線');
}
client.value.emit('player:request-game-console-state');
}
return {
...
requestGameConsoleState,
}
}
最後在 player-gamepad 呼叫。
src\views\player-gamepad.vue
...
<script setup lang="ts">
...
function init() {
...
player.onGameConsoleStateUpdate((state) => { ... });
player.requestGameConsoleState();
}
init();
</script>
現在我們只要在伺服器回應玩家網頁的請求就完成了!( ´ ▽ ` )ノ
讓我們回到伺服器專案。
第一步就是先新增事件定義。
types\socket.type.ts
...
export interface OnEvents {
'player:join-room': (data: Room) => void;
'player:request-game-console-state': () => void;
...
}
...
並在 game-console.gateway 處理事件。
src\game-console\game-console.gateway.ts
...
@WebSocketGateway()
export class GameConsoleGateway {
...
@SubscribeMessage<keyof OnEvents>('player:request-game-console-state')
async handleRequestState(socket: ClientSocket) {
const client = this.wsClientService.getClient({
socketId: socket.id,
});
if (!client) {
const result: SocketResponse = {
status: 'err',
message: '此 socket 不存在 client',
};
return result;
}
const room = this.roomService.getRoom({
playerId: client.id,
});
if (!room) {
const result: SocketResponse = {
status: 'err',
message: 'client 未加入任何房間',
};
return result;
}
const state = this.gameConsoleService.getState(room.founderId);
if (!state) {
const result: SocketResponse = {
status: 'err',
message: '此房間之 game-console 不存在 state',
};
return result;
}
socket.emit('game-console:state-update', state);
const result: SocketResponse<GameConsoleState> = {
status: 'suc',
message: '取得 state 成功',
data: state,
};
return result;
}
}
鱈魚:「現在是真的完成了!✧*。٩(ˊᗜˋ*)و✧*。」
讀者:「真的嗎?(́◉◞౪◟◉‵)」
鱈魚:「真的真的,有影片有真相 ( •̀ ω •́ )✧」
同時也會在 console 中看到事件觸發的訊息。
讀者:「所以…我說那個搖桿呢?(´・ω・`)」
鱈魚:「啊…(́⊙◞౪◟⊙‵)」
以上程式碼已同步至 GitLab,大家可以前往下載: